{
  "nbformat": 4,
  "nbformat_minor": 0,
  "metadata": {
    "colab": {
      "provenance": [],
      "authorship_tag": "ABX9TyOe5BsaH0aplNCjknkFtnjg",
      "include_colab_link": true
    },
    "kernelspec": {
      "name": "python3",
      "display_name": "Python 3"
    },
    "language_info": {
      "name": "python"
    }
  },
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "view-in-github",
        "colab_type": "text"
      },
      "source": [
        "<a href=\"https://colab.research.google.com/github/mshumer/OpenDeepResearcher/blob/main/open_deep_researcher.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
      ]
    },
    {
      "cell_type": "code",
      "source": [
        "!pip install nest_asyncio\n",
        "import nest_asyncio\n",
        "nest_asyncio.apply()"
      ],
      "metadata": {
        "colab": {
          "base_uri": "https://localhost:8080/"
        },
        "id": "y7cTpP9rDZW-",
        "outputId": "5a443ad2-7a8d-4fef-f315-12108c28f1a2"
      },
      "execution_count": null,
      "outputs": [
        {
          "output_type": "stream",
          "name": "stdout",
          "text": [
            "Requirement already satisfied: nest_asyncio in /usr/local/lib/python3.11/dist-packages (1.6.0)\n"
          ]
        }
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "GJTo96a7DGUz"
      },
      "outputs": [],
      "source": [
        "import asyncio\n",
        "import aiohttp\n",
        "import json\n",
        "\n",
        "# =======================\n",
        "# Configuration Constants\n",
        "# =======================\n",
        "OPENROUTER_API_KEY = \"REDACTED\" # Replace with your OpenRouter API key\n",
        "SERPAPI_API_KEY = \"REDACTED\" # Replace with your SERPAPI API key\n",
        "JINA_API_KEY = \"REDACTED\" # Replace with your JINA API key\n",
        "\n",
        "# Endpoints\n",
        "OPENROUTER_URL = \"https://openrouter.ai/api/v1/chat/completions\"\n",
        "SERPAPI_URL = \"https://serpapi.com/search\"\n",
        "JINA_BASE_URL = \"https://r.jina.ai/\"\n",
        "\n",
        "# Default LLM model (can be changed if desired)\n",
        "DEFAULT_MODEL = \"anthropic/claude-3.5-haiku\"\n",
        "\n",
        "\n",
        "# ============================\n",
        "# Asynchronous Helper Functions\n",
        "# ============================\n",
        "\n",
        "async def call_openrouter_async(session, messages, model=DEFAULT_MODEL):\n",
        "    \"\"\"\n",
        "    Asynchronously call the OpenRouter chat completion API with the provided messages.\n",
        "    Returns the content of the assistant’s reply.\n",
        "    \"\"\"\n",
        "    headers = {\n",
        "        \"Authorization\": f\"Bearer {OPENROUTER_API_KEY}\",\n",
        "        \"X-Title\": \"OpenDeepResearcher, by Matt Shumer\",\n",
        "        \"Content-Type\": \"application/json\"\n",
        "    }\n",
        "    payload = {\n",
        "        \"model\": model,\n",
        "        \"messages\": messages\n",
        "    }\n",
        "    try:\n",
        "        async with session.post(OPENROUTER_URL, headers=headers, json=payload) as resp:\n",
        "            if resp.status == 200:\n",
        "                result = await resp.json()\n",
        "                try:\n",
        "                    return result['choices'][0]['message']['content']\n",
        "                except (KeyError, IndexError) as e:\n",
        "                    print(\"Unexpected OpenRouter response structure:\", result)\n",
        "                    return None\n",
        "            else:\n",
        "                text = await resp.text()\n",
        "                print(f\"OpenRouter API error: {resp.status} - {text}\")\n",
        "                return None\n",
        "    except Exception as e:\n",
        "        print(\"Error calling OpenRouter:\", e)\n",
        "        return None\n",
        "\n",
        "\n",
        "async def generate_search_queries_async(session, user_query):\n",
        "    \"\"\"\n",
        "    Ask the LLM to produce up to four precise search queries (in Python list format)\n",
        "    based on the user’s query.\n",
        "    \"\"\"\n",
        "    prompt = (\n",
        "        \"You are an expert research assistant. Given the user's query, generate up to four distinct, \"\n",
        "        \"precise search queries that would help gather comprehensive information on the topic. \"\n",
        "        \"Return only a Python list of strings, for example: ['query1', 'query2', 'query3'].\"\n",
        "    )\n",
        "    messages = [\n",
        "        {\"role\": \"system\", \"content\": \"You are a helpful and precise research assistant.\"},\n",
        "        {\"role\": \"user\", \"content\": f\"User Query: {user_query}\\n\\n{prompt}\"}\n",
        "    ]\n",
        "    response = await call_openrouter_async(session, messages)\n",
        "    if response:\n",
        "        try:\n",
        "            # Expect exactly a Python list (e.g., \"['query1', 'query2']\")\n",
        "            search_queries = eval(response)\n",
        "            if isinstance(search_queries, list):\n",
        "                return search_queries\n",
        "            else:\n",
        "                print(\"LLM did not return a list. Response:\", response)\n",
        "                return []\n",
        "        except Exception as e:\n",
        "            print(\"Error parsing search queries:\", e, \"\\nResponse:\", response)\n",
        "            return []\n",
        "    return []\n",
        "\n",
        "\n",
        "async def perform_search_async(session, query):\n",
        "    \"\"\"\n",
        "    Asynchronously perform a Google search using SERPAPI for the given query.\n",
        "    Returns a list of result URLs.\n",
        "    \"\"\"\n",
        "    params = {\n",
        "        \"q\": query,\n",
        "        \"api_key\": SERPAPI_API_KEY,\n",
        "        \"engine\": \"google\"\n",
        "    }\n",
        "    try:\n",
        "        async with session.get(SERPAPI_URL, params=params) as resp:\n",
        "            if resp.status == 200:\n",
        "                results = await resp.json()\n",
        "                if \"organic_results\" in results:\n",
        "                    links = [item.get(\"link\") for item in results[\"organic_results\"] if \"link\" in item]\n",
        "                    return links\n",
        "                else:\n",
        "                    print(\"No organic results in SERPAPI response.\")\n",
        "                    return []\n",
        "            else:\n",
        "                text = await resp.text()\n",
        "                print(f\"SERPAPI error: {resp.status} - {text}\")\n",
        "                return []\n",
        "    except Exception as e:\n",
        "        print(\"Error performing SERPAPI search:\", e)\n",
        "        return []\n",
        "\n",
        "\n",
        "async def fetch_webpage_text_async(session, url):\n",
        "    \"\"\"\n",
        "    Asynchronously retrieve the text content of a webpage using Jina.\n",
        "    The URL is appended to the Jina endpoint.\n",
        "    \"\"\"\n",
        "    full_url = f\"{JINA_BASE_URL}{url}\"\n",
        "    headers = {\n",
        "        \"Authorization\": f\"Bearer {JINA_API_KEY}\"\n",
        "    }\n",
        "    try:\n",
        "        async with session.get(full_url, headers=headers) as resp:\n",
        "            if resp.status == 200:\n",
        "                return await resp.text()\n",
        "            else:\n",
        "                text = await resp.text()\n",
        "                print(f\"Jina fetch error for {url}: {resp.status} - {text}\")\n",
        "                return \"\"\n",
        "    except Exception as e:\n",
        "        print(\"Error fetching webpage text with Jina:\", e)\n",
        "        return \"\"\n",
        "\n",
        "\n",
        "async def is_page_useful_async(session, user_query, page_text):\n",
        "    \"\"\"\n",
        "    Ask the LLM if the provided webpage content is useful for answering the user's query.\n",
        "    The LLM must reply with exactly \"Yes\" or \"No\".\n",
        "    \"\"\"\n",
        "    prompt = (\n",
        "        \"You are a critical research evaluator. Given the user's query and the content of a webpage, \"\n",
        "        \"determine if the webpage contains information relevant and useful for addressing the query. \"\n",
        "        \"Respond with exactly one word: 'Yes' if the page is useful, or 'No' if it is not. Do not include any extra text.\"\n",
        "    )\n",
        "    messages = [\n",
        "        {\"role\": \"system\", \"content\": \"You are a strict and concise evaluator of research relevance.\"},\n",
        "        {\"role\": \"user\", \"content\": f\"User Query: {user_query}\\n\\nWebpage Content (first 20000 characters):\\n{page_text[:20000]}\\n\\n{prompt}\"}\n",
        "    ]\n",
        "    response = await call_openrouter_async(session, messages)\n",
        "    if response:\n",
        "        answer = response.strip()\n",
        "        if answer in [\"Yes\", \"No\"]:\n",
        "            return answer\n",
        "        else:\n",
        "            # Fallback: try to extract Yes/No from the response.\n",
        "            if \"Yes\" in answer:\n",
        "                return \"Yes\"\n",
        "            elif \"No\" in answer:\n",
        "                return \"No\"\n",
        "    return \"No\"\n",
        "\n",
        "\n",
        "async def extract_relevant_context_async(session, user_query, search_query, page_text):\n",
        "    \"\"\"\n",
        "    Given the original query, the search query used, and the page content,\n",
        "    have the LLM extract all information relevant for answering the query.\n",
        "    \"\"\"\n",
        "    prompt = (\n",
        "        \"You are an expert information extractor. Given the user's query, the search query that led to this page, \"\n",
        "        \"and the webpage content, extract all pieces of information that are relevant to answering the user's query. \"\n",
        "        \"Return only the relevant context as plain text without commentary.\"\n",
        "    )\n",
        "    messages = [\n",
        "        {\"role\": \"system\", \"content\": \"You are an expert in extracting and summarizing relevant information.\"},\n",
        "        {\"role\": \"user\", \"content\": f\"User Query: {user_query}\\nSearch Query: {search_query}\\n\\nWebpage Content (first 20000 characters):\\n{page_text[:20000]}\\n\\n{prompt}\"}\n",
        "    ]\n",
        "    response = await call_openrouter_async(session, messages)\n",
        "    if response:\n",
        "        return response.strip()\n",
        "    return \"\"\n",
        "\n",
        "\n",
        "async def get_new_search_queries_async(session, user_query, previous_search_queries, all_contexts):\n",
        "    \"\"\"\n",
        "    Based on the original query, the previously used search queries, and all the extracted contexts,\n",
        "    ask the LLM whether additional search queries are needed. If yes, return a Python list of up to four queries;\n",
        "    if the LLM thinks research is complete, it should return \"<done>\".\n",
        "    \"\"\"\n",
        "    context_combined = \"\\n\".join(all_contexts)\n",
        "    prompt = (\n",
        "        \"You are an analytical research assistant. Based on the original query, the search queries performed so far, \"\n",
        "        \"and the extracted contexts from webpages, determine if further research is needed. \"\n",
        "        \"If further research is needed, provide up to four new search queries as a Python list (for example, \"\n",
        "        \"['new query1', 'new query2']). If you believe no further research is needed, respond with exactly <done>.\"\n",
        "        \"\\nOutput only a Python list or the token <done> without any additional text.\"\n",
        "    )\n",
        "    messages = [\n",
        "        {\"role\": \"system\", \"content\": \"You are a systematic research planner.\"},\n",
        "        {\"role\": \"user\", \"content\": f\"User Query: {user_query}\\nPrevious Search Queries: {previous_search_queries}\\n\\nExtracted Relevant Contexts:\\n{context_combined}\\n\\n{prompt}\"}\n",
        "    ]\n",
        "    response = await call_openrouter_async(session, messages)\n",
        "    if response:\n",
        "        cleaned = response.strip()\n",
        "        if cleaned == \"<done>\":\n",
        "            return \"<done>\"\n",
        "        try:\n",
        "            new_queries = eval(cleaned)\n",
        "            if isinstance(new_queries, list):\n",
        "                return new_queries\n",
        "            else:\n",
        "                print(\"LLM did not return a list for new search queries. Response:\", response)\n",
        "                return []\n",
        "        except Exception as e:\n",
        "            print(\"Error parsing new search queries:\", e, \"\\nResponse:\", response)\n",
        "            return []\n",
        "    return []\n",
        "\n",
        "\n",
        "async def generate_final_report_async(session, user_query, all_contexts):\n",
        "    \"\"\"\n",
        "    Generate the final comprehensive report using all gathered contexts.\n",
        "    \"\"\"\n",
        "    context_combined = \"\\n\".join(all_contexts)\n",
        "    prompt = (\n",
        "        \"You are an expert researcher and report writer. Based on the gathered contexts below and the original query, \"\n",
        "        \"write a comprehensive, well-structured, and detailed report that addresses the query thoroughly. \"\n",
        "        \"Include all relevant insights and conclusions without extraneous commentary.\"\n",
        "    )\n",
        "    messages = [\n",
        "        {\"role\": \"system\", \"content\": \"You are a skilled report writer.\"},\n",
        "        {\"role\": \"user\", \"content\": f\"User Query: {user_query}\\n\\nGathered Relevant Contexts:\\n{context_combined}\\n\\n{prompt}\"}\n",
        "    ]\n",
        "    report = await call_openrouter_async(session, messages)\n",
        "    return report\n",
        "\n",
        "\n",
        "async def process_link(session, link, user_query, search_query):\n",
        "    \"\"\"\n",
        "    Process a single link: fetch its content, judge its usefulness, and if useful, extract the relevant context.\n",
        "    \"\"\"\n",
        "    print(f\"Fetching content from: {link}\")\n",
        "    page_text = await fetch_webpage_text_async(session, link)\n",
        "    if not page_text:\n",
        "        return None\n",
        "    usefulness = await is_page_useful_async(session, user_query, page_text)\n",
        "    print(f\"Page usefulness for {link}: {usefulness}\")\n",
        "    if usefulness == \"Yes\":\n",
        "        context = await extract_relevant_context_async(session, user_query, search_query, page_text)\n",
        "        if context:\n",
        "            print(f\"Extracted context from {link} (first 200 chars): {context[:200]}\")\n",
        "            return context\n",
        "    return None\n",
        "\n",
        "\n",
        "# =========================\n",
        "# Main Asynchronous Routine\n",
        "# =========================\n",
        "\n",
        "async def async_main():\n",
        "    user_query = input(\"Enter your research query/topic: \").strip()\n",
        "    iter_limit_input = input(\"Enter maximum number of iterations (default 10): \").strip()\n",
        "    iteration_limit = int(iter_limit_input) if iter_limit_input.isdigit() else 10\n",
        "\n",
        "    aggregated_contexts = []    # All useful contexts from every iteration\n",
        "    all_search_queries = []     # Every search query used across iterations\n",
        "    iteration = 0\n",
        "\n",
        "    async with aiohttp.ClientSession() as session:\n",
        "        # ----- INITIAL SEARCH QUERIES -----\n",
        "        new_search_queries = await generate_search_queries_async(session, user_query)\n",
        "        if not new_search_queries:\n",
        "            print(\"No search queries were generated by the LLM. Exiting.\")\n",
        "            return\n",
        "        all_search_queries.extend(new_search_queries)\n",
        "\n",
        "        # ----- ITERATIVE RESEARCH LOOP -----\n",
        "        while iteration < iteration_limit:\n",
        "            print(f\"\\n=== Iteration {iteration + 1} ===\")\n",
        "            iteration_contexts = []\n",
        "\n",
        "            # For each search query, perform SERPAPI searches concurrently.\n",
        "            search_tasks = [perform_search_async(session, query) for query in new_search_queries]\n",
        "            search_results = await asyncio.gather(*search_tasks)\n",
        "\n",
        "            # Aggregate all unique links from all search queries of this iteration.\n",
        "            # Map each unique link to the search query that produced it.\n",
        "            unique_links = {}\n",
        "            for idx, links in enumerate(search_results):\n",
        "                query = new_search_queries[idx]\n",
        "                for link in links:\n",
        "                    if link not in unique_links:\n",
        "                        unique_links[link] = query\n",
        "\n",
        "            print(f\"Aggregated {len(unique_links)} unique links from this iteration.\")\n",
        "\n",
        "            # Process each link concurrently: fetch, judge, and extract context.\n",
        "            link_tasks = [\n",
        "                process_link(session, link, user_query, unique_links[link])\n",
        "                for link in unique_links\n",
        "            ]\n",
        "            link_results = await asyncio.gather(*link_tasks)\n",
        "\n",
        "            # Collect non-None contexts.\n",
        "            for res in link_results:\n",
        "                if res:\n",
        "                    iteration_contexts.append(res)\n",
        "\n",
        "            if iteration_contexts:\n",
        "                aggregated_contexts.extend(iteration_contexts)\n",
        "            else:\n",
        "                print(\"No useful contexts were found in this iteration.\")\n",
        "\n",
        "            # ----- ASK THE LLM IF MORE SEARCHES ARE NEEDED -----\n",
        "            new_search_queries = await get_new_search_queries_async(session, user_query, all_search_queries, aggregated_contexts)\n",
        "            if new_search_queries == \"<done>\":\n",
        "                print(\"LLM indicated that no further research is needed.\")\n",
        "                break\n",
        "            elif new_search_queries:\n",
        "                print(\"LLM provided new search queries:\", new_search_queries)\n",
        "                all_search_queries.extend(new_search_queries)\n",
        "            else:\n",
        "                print(\"LLM did not provide any new search queries. Ending the loop.\")\n",
        "                break\n",
        "\n",
        "            iteration += 1\n",
        "\n",
        "        # ----- FINAL REPORT -----\n",
        "        print(\"\\nGenerating final report...\")\n",
        "        final_report = await generate_final_report_async(session, user_query, aggregated_contexts)\n",
        "        print(\"\\n==== FINAL REPORT ====\\n\")\n",
        "        print(final_report)\n",
        "\n",
        "\n",
        "def main():\n",
        "    asyncio.run(async_main())\n",
        "\n",
        "\n",
        "if __name__ == \"__main__\":\n",
        "    main()\n"
      ]
    },
    {
      "cell_type": "code",
      "source": [],
      "metadata": {
        "id": "46Q5XpapDJZT"
      },
      "execution_count": null,
      "outputs": []
    }
  ]
}